SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
16.4 KB · 319 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Suspense } from 'react';5import { PageHeader } from '@/components/ui/page-header';6import { Card, CardHeader, Delta, EmptyState, Skeleton, Stat, Badge, Unavailable } from '@/components/ui/primitives';7import { MarketTable } from '@/components/market/market-table';8import { AssetBrowser } from '@/components/market/asset-browser';9import { Thumb } from '@/components/market/bits';10import type { SP } from '@/lib/search-params';11import { AssetTable } from '@/components/market/asset-list';12import { SalesTable, ListingsTable } from '@/components/market/sales-table';13import { LotsIntelTable } from '@/components/market/lots-table';14import { LineChart } from '@/components/charts/line-chart';15import { BarChart } from '@/components/charts/bar-chart';16import { getBrandsInCategory, getCategoryRow, getCategorySeries, getMarketRows, getPopulationTrend, getSetsInCategory } from '@/lib/queries/markets';17import { rankedAssets, categoryScope } from '@/lib/queries/assets';18import { getRecentSalesInScope, getTopSales, listListings, listLots, listNews, listRadar } from '@/lib/queries/market-lists';19import { getIndex } from '@/lib/queries/indices';20import { fmtMoney, fmtNum, fmtRelative, fmtDate } from '@/lib/format';21import { categoryCrumbs, getCategory, humanize } from '@/lib/taxonomy';22import { CATEGORIES } from '@rareindex/taxonomy';2324export const revalidate = 300;2526export async function generateStaticParams() {27  return CATEGORIES.filter((c) => c.phase === 1).map((c) => ({ slug: c.slug }));28}2930export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {31  const { slug } = await params;32  const c = getCategory(slug);33  if (!c) return { title: 'Market not found' };34  return { title: `${c.name} market`, description: `${c.name} collectibles market on RareIndex: index, sales, volume, top movers, most valuable assets, listings and news.` };35}3637export default async function MarketPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {38  const { slug } = await params;39  const sp = await searchParams;40  const row = await getCategoryRow(slug);41  if (!row) notFound();42  const { node, snapshot, counts } = row;43  const index = node.index ? await getIndex(node.index) : null;44  const subcategories = CATEGORIES.filter((c) => c.parent === slug);45  return (46    <div>47      <PageHeader48        crumbs={[{ label: 'Markets', href: '/markets' }, ...categoryCrumbs(slug)]}49        title={node.name}50        description={node.description ?? `${node.name} market data: verified sales, live listings, valuations and category index.`}51        meta={52          <>53            <Badge tone={node.phase === 1 ? 'index' : 'neutral'}>Phase {node.phase}</Badge>54            {index ? (55              <Link href={`/rareindex/${index.ticker}`} className="hover:text-fg">56                Subindex {index.ticker}57              </Link>58            ) : null}59            {node.compliance?.length ? <Badge tone="alert">{node.compliance.map(humanize).join(' · ')}</Badge> : null}60            <Link href={`/explore?category=${slug}`} className="hover:text-fg">61              Explore assets →62            </Link>63          </>64        }65      />6667      <section className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-4 lg:grid-cols-8">68        {[69          ['Tracked assets', fmtNum(counts.assets)],70          ['With valuation', fmtNum(counts.priced)],71          ['Sales (all time)', fmtNum(counts.sales)],72          ['Sales 30D', fmtNum(counts.sales30d)],73          ['Volume 30D', counts.volume30dUsd != null ? fmtMoney(counts.volume30dUsd, 'USD', { compact: true }) : '—'],74          ['Active listings', fmtNum(counts.listings)],75          ['Median RIV', counts.medianRivUsd != null ? fmtMoney(counts.medianRivUsd) : '—'],76          ['Market cap est.', snapshot?.marketCapEstUsd != null ? fmtMoney(snapshot.marketCapEstUsd, 'USD', { compact: true }) : '—'],77        ].map(([k, v]) => (78          <div key={k} className="bg-elevated px-3 py-2.5">79            <Stat label={k} value={v} />80          </div>81        ))}82      </section>83      {snapshot?.marketCapEstUsd != null ? <p className="mt-1 text-[11px] text-subtle">Market cap estimate = estimated population × representative RIV per asset, summed; indicative only (§124).</p> : null}84      {counts.assets > 0 && counts.priced === 0 ? (85        <p className="mt-2 rounded-md border border-border bg-sunken px-3 py-2 text-[12px] text-muted">86          <span className="font-medium text-fg">{fmtNum(counts.assets)} {node.name} assets are catalogued.</span> Valuations publish per asset once enough verified sales exist; guide prices from price-guide sources are shown in the meantime, labelled as such (never presented as market value).87        </p>88      ) : null}8990      <div className="mt-4">91        <Suspense fallback={<Skeleton className="h-[32rem]" />}>92          <AssetBrowser scope={{ category: slug }} sp={sp} basePath={`/markets/${slug}`} title={`Assets in ${node.name}`} subcategories={subcategories} />93        </Suspense>94      </div>9596      {counts.sales > 0 || counts.listings > 0 ? (97        <>98          <Suspense fallback={<Skeleton className="mt-4 h-72" />}>99            <IndexPanel slug={slug} />100          </Suspense>101102          <Suspense fallback={<Skeleton className="mt-4 h-72" />}>103            <Movers slug={slug} />104          </Suspense>105106          <Suspense fallback={<Skeleton className="mt-4 h-72" />}>107            <Activity slug={slug} />108          </Suspense>109        </>110      ) : (111        <Card className="mt-4 p-4">112          <p className="t-subtitle text-fg">Market analytics publish once verified sales or live listings exist in {node.name}.</p>113          <p className="mt-1 text-[13px] leading-relaxed text-muted">114            Movers, volume, records, value opportunities, auctions and the category index all derive from observed transactions. Until then the catalogue above is complete and guide prices are shown where a price-guide source reports them (labelled, never presented as market value).{' '}115            <Link href="/data" className="underline-offset-2 hover:text-fg hover:underline">116              How coverage grows →117            </Link>118          </p>119        </Card>120      )}121122      <Suspense fallback={<Skeleton className="mt-4 h-40" />}>123        <Structure slug={slug} />124      </Suspense>125    </div>126  );127}128129async function IndexPanel({ slug }: { slug: string }) {130  const series = await getCategorySeries(slug, 730);131  const points = series.filter((p) => p.indexValue != null).map((p) => ({ x: p.date, y: p.indexValue! }));132  const salesSeries = series.map((p) => ({ x: p.date, y: p.sales }));133  return (134    <section className="mt-4 grid gap-4 lg:grid-cols-[2fr_1fr]">135      <Card className="p-4">136        <h2 className="text-sm font-semibold">Category index</h2>137        <p className="text-xs text-muted">Daily category index from valuations of priced constituents; publishes once enough assets are priced.</p>138        <div className="mt-2">139          <LineChart ariaLabel={`${slug} category index`} height={220} series={[{ id: 'idx', label: 'Category index', kind: 'area', points }]} emptyLabel="Index building — not enough priced constituents yet" />140        </div>141      </Card>142      <Card className="p-4">143        <h2 className="text-sm font-semibold">Daily sales</h2>144        <p className="text-xs text-muted">Verified transactions per day (count)</p>145        <div className="mt-2">146          <LineChart ariaLabel={`${slug} daily sales`} height={220} series={[{ id: 'sales', label: 'Sales', kind: 'step', points: salesSeries }]} emptyLabel="No snapshot history yet" />147        </div>148      </Card>149    </section>150  );151}152153async function Movers({ slug }: { slug: string }) {154  const scope = categoryScope(slug);155  const [gainers, losers, volume, expensive, trending] = await Promise.all([156    rankedAssets('gainers', { scope, limit: 8, window: '7d' }),157    rankedAssets('losers', { scope, limit: 8, window: '7d' }),158    rankedAssets('volume', { scope, limit: 8 }),159    rankedAssets('expensive', { scope, limit: 8 }),160    rankedAssets('trending', { scope, limit: 8 }),161  ]);162  const block = (title: string, sub: string, items: typeof gainers, columns: Parameters<typeof AssetTable>[0]['columns']) => (163    <Card>164      <CardHeader title={title} subtitle={sub} />165      <AssetTable items={items} columns={columns} emptyTitle="No data yet" emptyDescription="Needs priced assets with sales history in this category." />166    </Card>167  );168  return (169    <section className="mt-4 grid gap-4 xl:grid-cols-2">170      {block('Top gainers · 7D', 'Assets with ≥3 sales used in valuation', gainers, ['riv', 'change7d', 'change30d', 'sales'])}171      {block('Top losers · 7D', 'Assets with ≥3 sales used in valuation', losers, ['riv', 'change7d', 'change30d', 'sales'])}172      {block('Highest volume · 30D', 'Sum of verified sale prices, USD', volume, ['riv', 'sales30d', 'change30d', 'liquidity'])}173      {block('Most valuable', 'Ranked by RareIndex Valuation', expensive, ['riv', 'latestSale', 'change1y', 'rarity'])}174      {block('Trending', 'Price × volume × listing momentum', trending, ['riv', 'trending', 'change7d', 'sales30d'])}175      <Card>176        <CardHeader title="Value opportunities" subtitle="Assets whose lowest ask sits materially below RIV — analytical data, not advice" />177        <AssetTable items={await rankedAssets('opportunity', { scope, limit: 8 })} columns={['riv', 'minAsk', 'opportunity', 'listings']} emptyTitle="No opportunities detected" emptyDescription="Requires both a valuation and active listings." />178      </Card>179    </section>180  );181}182183async function Activity({ slug }: { slug: string }) {184  const scope = categoryScope(slug);185  const [records, recent, listings, lots, news, radar, pop] = await Promise.all([186    getTopSales(6, scope),187    getRecentSalesInScope(scope, 10),188    listListings({ category: slug, pageSize: 10, sort: 'newest' }),189    listLots({ category: slug, limit: 6, sort: 'value' }),190    listNews({ category: slug, limit: 6 }),191    listRadar({ scope, limit: 6 }),192    getPopulationTrend(slug),193  ]);194  const popByGrader = new Map<string, Array<{ x: string; y: number }>>();195  for (const p of pop) (popByGrader.get(p.grader) ?? popByGrader.set(p.grader, []).get(p.grader)!).push({ x: p.reportDate, y: p.total });196  return (197    <section className="mt-4 grid gap-4 xl:grid-cols-2">198      <Card>199        <CardHeader title="Newest records" subtitle="Highest verified sales in this market" action={<Link href={`/records`} className="text-muted hover:text-fg">All records →</Link>} />200        <SalesTable items={records} emptyDescription="No verified sales in this category yet." />201      </Card>202      <Card>203        <CardHeader title="Latest sales" action={<Link href={`/sales?category=${slug}`} className="text-muted hover:text-fg">All sales →</Link>} />204        <SalesTable items={recent} emptyDescription="No sales in this category yet." />205      </Card>206      <Card className="xl:col-span-2">207        <CardHeader title="Live listings" subtitle="Asking prices are not market values" action={<Link href={`/listings?category=${slug}`} className="text-muted hover:text-fg">All listings →</Link>} />208        <ListingsTable items={listings.items} />209      </Card>210      <Card>211        <CardHeader title="Biggest auctions" subtitle="Lots by hammer, bid or high estimate" action={<Link href="/auctions" className="text-muted hover:text-fg">Auctions →</Link>} />212        <LotsIntelTable items={lots} />213      </Card>214      <Card>215        <CardHeader title="Population trends" subtitle="Total graded population reported per grader" />216        {popByGrader.size ? (217          <div className="p-4">218            <LineChart ariaLabel="Population trend" height={200} series={[...popByGrader.entries()].map(([g, pts]) => ({ id: g, label: g.toUpperCase(), points: pts }))} />219          </div>220        ) : (221          <EmptyState title="No population reports" description="Population data appears when grading-company connectors publish reports for assets in this category." className="py-8" />222        )}223      </Card>224      <Card>225        <CardHeader title="Rare Radar" action={<Link href="/radar" className="text-muted hover:text-fg">Open radar →</Link>} />226        {radar.length ? (227          <ul className="divide-y divide-border text-[12px]">228            {radar.map((r) => (229              <li key={r.id} className="flex items-center justify-between gap-3 px-4 py-2">230                <Link href={`/asset/${r.assetSlug}`} className="truncate font-medium text-fg hover:underline">231                  {r.assetTitle}232                </Link>233                <span className="shrink-0 text-muted">234                  {humanize(r.kind)} · {fmtRelative(r.detectedAt)}235                </span>236              </li>237            ))}238          </ul>239        ) : (240          <EmptyState title="Radar is quiet" className="py-8" />241        )}242      </Card>243      <Card>244        <CardHeader title="News" action={<Link href={`/news?category=${slug}`} className="text-muted hover:text-fg">All news →</Link>} />245        {news.items.length ? (246          <ul className="divide-y divide-border text-[12px]">247            {news.items.map((n) => (248              <li key={n.id} className="px-4 py-2">249                <a href={n.url} target="_blank" rel="noopener nofollow" className="font-medium text-fg hover:underline">250                  {n.title}251                </a>252                <span className="block text-[11px] text-muted">253                  {n.sourceName} · {n.publishedAt ? fmtDate(n.publishedAt) : 'undated'}254                </span>255              </li>256            ))}257          </ul>258        ) : (259          <EmptyState title="No news indexed" description="News connectors are not yet publishing for this market." className="py-8" />260        )}261      </Card>262    </section>263  );264}265266async function Structure({ slug }: { slug: string }) {267  const [children, sets, brands] = await Promise.all([getMarketRows(slug), getSetsInCategory(slug, 40), getBrandsInCategory(slug, 30)]);268  return (269    <section className="mt-4 grid gap-4">270      {children.length ? (271        <Card>272          <CardHeader title="Subcategories" />273          <MarketTable rows={children} showPhase />274        </Card>275      ) : null}276      <div className="grid gap-4 lg:grid-cols-2">277        <Card>278          <CardHeader title="Sets & releases" subtitle={`${fmtNum(sets.length)}${sets.length >= 40 ? '+' : ''} releases catalogued · ranked by sales, then size`} action={<Link href={`/markets/${slug}?sort=number#assets`} className="text-muted hover:text-fg">Browse by set →</Link>} />279          {sets.length ? (280            <ul className="grid grid-cols-1 text-[12px] sm:grid-cols-2">281              {sets.map((s) => (282                <li key={s.slug} className="flex items-center gap-2 border-b border-border px-4 py-1.5">283                  <Thumb src={s.thumb} alt="" size={28} />284                  <Link href={`/set/${s.slug}`} className="min-w-0 flex-1 truncate font-medium text-fg hover:underline">285                    {s.name}286                    {s.code ? <span className="ml-1 text-subtle">{s.code}</span> : null}287                    {s.releaseYear ? <span className="ml-1 text-subtle">{s.releaseYear}</span> : null}288                  </Link>289                  <span className="num shrink-0 text-muted">290                    {fmtNum(s.assets)}{s.sales ? ` · ${fmtNum(s.sales)} sales` : ''}{s.priced ? ` · ${fmtNum(s.priced)} priced` : ''}291                  </span>292                </li>293              ))}294            </ul>295          ) : (296            <EmptyState title="No sets catalogued" className="py-8" />297          )}298        </Card>299        <Card>300          <CardHeader title="Brands" subtitle="Median RIV of priced assets" />301          {brands.length ? (302            <div className="p-4">303              <BarChart ariaLabel="Assets per brand" data={brands.slice(0, 12).map((b) => ({ label: b.brand, value: b.assets, sublabel: `${b.priced} priced · median RIV ${b.medianRivUsd != null ? fmtMoney(b.medianRivUsd) : '—'}`, href: `/brand/${encodeURIComponent(b.brand)}` }))} />304            </div>305          ) : (306            <EmptyState title="No brand data" className="py-8" />307          )}308        </Card>309      </div>310      <p className="text-[11px] text-subtle">311        Unavailable metrics render as <Unavailable /> rather than estimates (§192).312      </p>313      <span className="hidden">314        <Delta value={null} />315      </span>316    </section>317  );318}319